--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit e5e0bbd36d3efa19375bd2c7c574bba93c57b728
Parents : 1afb2d9
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-06T01:03:23-05:00
feat(auto-propagation) fix to not get stuck on a node we cant reach.
Changes
5 files changed, 180 insertions(+), 9 deletions(-)
Diff
diff --git a/meshchatx/src/backend/auto_propagation_manager.py b/meshchatx/src/backend/auto_propagation_manager.py
index 1da9b4ce..58da785e 100644
--- a/meshchatx/src/backend/auto_propagation_manager.py
+++ b/meshchatx/src/backend/auto_propagation_manager.py
@@ -106,8 +106,27 @@ class AutoPropagationManager:
ctx = self.context
router = ctx.message_router
+ previous_hex = (
+ self.config.lxmf_preferred_propagation_node_destination_hash.get()
+ )
+
+ # If a sync is in progress, only interrupt it when the current node
+ # appears unreachable. This prevents getting stuck on a node we
+ # cannot get a path to.
if router.propagation_transfer_state != LXMRouter.PR_IDLE:
- return
+ current_has_path = False
+ if previous_hex:
+ try:
+ current_dest = bytes.fromhex(previous_hex)
+ current_has_path = RNS.Transport.has_path(current_dest)
+ except Exception:
+ pass
+ if current_has_path:
+ # Sync is likely making progress – let it finish.
+ return
+ # Current node is unreachable – stop the stuck sync so we can
+ # look for a working alternative.
+ self.app.stop_propagation_node_sync(context=ctx)
announces = self.database.announces.get_announces(aspect="lxmf.propagation")
@@ -133,9 +152,6 @@ class AutoPropagationManager:
if not sorted_candidates:
return
- previous_hex = (
- self.config.lxmf_preferred_propagation_node_destination_hash.get()
- )
ordered: list[tuple[int, str]] = []
seen_hex: set[str] = set()
if previous_hex and previous_hex in best_by_hex:
@@ -172,7 +188,18 @@ class AutoPropagationManager:
)
return
+ # None of the candidates worked. If the previously-selected node is
+ # still unreachable, clear it rather than restoring a broken node.
if previous_hex:
- self.app.set_active_propagation_node(previous_hex, context=self.context)
+ try:
+ previous_dest = bytes.fromhex(previous_hex)
+ if RNS.Transport.has_path(previous_dest):
+ self.app.set_active_propagation_node(
+ previous_hex, context=self.context
+ )
+ return
+ except Exception:
+ pass
+ self.app.remove_active_propagation_node(context=self.context)
else:
self.app.remove_active_propagation_node(context=self.context)
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index f3083f48..71c51e76 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -101,6 +101,18 @@
>
<MaterialDesignIcon icon-name="phone" class="w-5 h-5 sm:w-6 sm:h-6" />
</button>
+ <button
+ type="button"
+ class="sm:hidden rounded-full p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
+ :title="isSyncingPropagationNode ? $t('app.syncing') : $t('app.sync_messages')"
+ @click="syncPropagationNode"
+ >
+ <MaterialDesignIcon
+ icon-name="refresh"
+ class="w-5 h-5"
+ :class="{ 'animate-spin': isSyncingPropagationNode }"
+ />
+ </button>
<button type="button" class="hidden sm:flex rounded-full" @click="syncPropagationNode">
<span
class="flex text-gray-800 dark:text-zinc-100 bg-white dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 hover:border-blue-400 dark:hover:border-blue-400/60 px-3 py-1.5 rounded-full shadow-xs transition"
diff --git a/meshchatx/src/frontend/components/Toast.vue b/meshchatx/src/frontend/components/Toast.vue
index 221fe5f6..0fcdcd11 100644
--- a/meshchatx/src/frontend/components/Toast.vue
+++ b/meshchatx/src/frontend/components/Toast.vue
@@ -228,11 +228,15 @@ export default {
.toast-swipe-out-left {
transform: translateX(-120%) !important;
opacity: 0 !important;
- transition: transform 0.25s ease, opacity 0.25s ease !important;
+ transition:
+ transform 0.25s ease,
+ opacity 0.25s ease !important;
}
.toast-swipe-out-right {
transform: translateX(120%) !important;
opacity: 0 !important;
- transition: transform 0.25s ease, opacity 0.25s ease !important;
+ transition:
+ transform 0.25s ease,
+ opacity 0.25s ease !important;
}
</style>
diff --git a/tests/backend/test_auto_propagation.py b/tests/backend/test_auto_propagation.py
index e9538a10..cbc72c2b 100644
--- a/tests/backend/test_auto_propagation.py
+++ b/tests/backend/test_auto_propagation.py
@@ -15,8 +15,7 @@ _VALID_HASH_C = "03" * 16
_APP_DATA_ENABLED = b"\x94\x00\x00\x01\x00"
-@pytest.mark.asyncio
-async def test_auto_propagation_logic():
+def _make_manager():
app = MagicMock()
context = MagicMock()
config = MagicMock()
@@ -30,6 +29,12 @@ async def test_auto_propagation_logic():
context.message_router.propagation_transfer_state = LXMRouter.PR_IDLE
manager = AutoPropagationManager(app, context)
+ return manager, app, context, config, database
+
+
+@pytest.mark.asyncio
+async def test_auto_propagation_logic():
+ manager, app, context, config, database = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = False
with patch.object(manager, "check_and_update_propagation_node") as mock_check:
@@ -115,3 +120,97 @@ async def test_auto_propagation_logic():
await manager.check_and_update_propagation_node()
app.set_active_propagation_node.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_auto_propagation_skips_when_sync_active_and_path_exists():
+ """Skip auto-propagation changes while sync is active and the path exists.
+
+ When a sync is active and the current node still has a path, the manager
+ should leave it alone so the transfer can finish.
+ """
+ manager, app, context, config, database = _make_manager()
+
+ config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
+ config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
+ _VALID_HASH_A
+ )
+ context.message_router.propagation_transfer_state = LXMRouter.PR_RECEIVING
+
+ with patch.object(RNS.Transport, "has_path", return_value=True):
+ await manager.check_and_update_propagation_node()
+
+ app.stop_propagation_node_sync.assert_not_called()
+ app.set_active_propagation_node.assert_not_called()
+ app.remove_active_propagation_node.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_auto_propagation_finds_new_node_when_sync_stuck_no_path():
+ """Recover when sync is stuck and the current node has no path.
+
+ The manager should stop the stuck sync and look for a working node.
+ """
+ manager, app, context, config, database = _make_manager()
+
+ config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
+ config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
+ _VALID_HASH_A
+ )
+ context.message_router.propagation_transfer_state = LXMRouter.PR_PATH_REQUESTED
+
+ announce1 = {
+ "destination_hash": _VALID_HASH_B,
+ "app_data": _APP_DATA_ENABLED,
+ }
+ database.announces.get_announces.return_value = [announce1]
+
+ with (
+ patch.object(RNS.Transport, "has_path") as mock_has_path,
+ patch.object(RNS.Transport, "hops_to", return_value=1),
+ patch.object(manager, "_wait_for_path", return_value=True),
+ patch.object(manager, "_probe_propagation_sync", return_value=True),
+ ):
+ # Current node A has no path, candidate B has a path.
+ mock_has_path.side_effect = lambda dh: dh == bytes.fromhex(_VALID_HASH_B)
+
+ await manager.check_and_update_propagation_node()
+
+ app.stop_propagation_node_sync.assert_called_once_with(context=context)
+ app.set_active_propagation_node.assert_called_once_with(
+ _VALID_HASH_B,
+ context=context,
+ )
+ config.lxmf_preferred_propagation_node_destination_hash.set.assert_called_with(
+ _VALID_HASH_B,
+ )
+
+
+@pytest.mark.asyncio
+async def test_auto_propagation_removes_broken_node_when_all_candidates_fail():
+ """Remove the active propagation node when no candidate works.
+
+ When no candidate works and the previous node is unreachable, the active
+ propagation node should be removed instead of restoring a broken one.
+ """
+ manager, app, context, config, database = _make_manager()
+
+ config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
+ config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
+ _VALID_HASH_A
+ )
+
+ announce1 = {
+ "destination_hash": _VALID_HASH_B,
+ "app_data": _APP_DATA_ENABLED,
+ }
+ database.announces.get_announces.return_value = [announce1]
+
+ with (
+ patch.object(RNS.Transport, "has_path", return_value=False),
+ patch.object(manager, "_wait_for_path", return_value=False),
+ ):
+ await manager.check_and_update_propagation_node()
+
+ app.set_active_propagation_node.assert_not_called()
+ app.remove_active_propagation_node.assert_called_once_with(context=context)
diff --git a/tests/frontend/UIThemeAndVisibility.test.js b/tests/frontend/UIThemeAndVisibility.test.js
index f6013e27..c8401d1d 100644
--- a/tests/frontend/UIThemeAndVisibility.test.js
+++ b/tests/frontend/UIThemeAndVisibility.test.js
@@ -573,6 +573,35 @@ describe("Conditional Rendering", () => {
const sidebarButton = wrapper.find("button.sm\\:hidden");
expect(sidebarButton.exists()).toBe(true);
});
+
+ it("App shows propagation sync refresh icon on mobile", async () => {
+ const wrapper = mount(App, {
+ global: {
+ stubs: {
+ RouterView: { template: "<div>Router View</div>" },
+ RouterLink: createRouterLinkStub(),
+ MaterialDesignIcon: { template: '<div data-icon-name="{{ iconName }}"></div>' },
+ LanguageSelector: { template: "<div></div>" },
+ NotificationBell: { template: "<div></div>" },
+ SidebarLink: {
+ template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
+ props: ["to", "isCollapsed"],
+ },
+ },
+ mocks: {
+ $route: { name: "messages", meta: {}, query: {} },
+ $router: { push: vi.fn() },
+ $t: (key) => key,
+ },
+ },
+ });
+
+ const mobileRefreshButtons = wrapper.findAll("button").filter((b) => {
+ const cls = b.classes().join(" ");
+ return cls.includes("sm:hidden") && b.attributes("title") === "app.sync_messages";
+ });
+ expect(mobileRefreshButtons.length).toBe(1);
+ });
});
describe("Dark Mode Class Application", () => {
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────